import { configs, context, events } from "@budibase/backend-core"
import { mocks } from "@budibase/backend-core/tests"
import {
  Automation,
  AutomationResults,
  AutomationTriggerStepId,
  ConfigType,
  EmailTrigger,
  EmailTriggerInputs,
  FieldType,
  FilterCondition,
  RowActionTriggerInputs,
  RowCreatedTriggerInputs,
  RowDeletedTriggerInputs,
  SettingsConfig,
  Table,
  isDidNotTriggerResponse,
  isEmailTrigger,
} from "@budibase/types"
import {
  BUILTIN_ACTION_DEFINITIONS,
  TRIGGER_DEFINITIONS,
} from "../../../automations"
import { createAutomationBuilder } from "../../../automations/tests/utilities/AutomationTestBuilder"
import sdk from "../../../sdk"
import { basicTable } from "../../../tests/utilities/structures"
import TestConfiguration from "../../../tests/utilities/TestConfiguration"
import * as setup from "./utilities"
import {
  checkBuilderEndpoint,
  getAllTableRows,
  testAutomation,
} from "./utilities/TestFunctions"

const MAX_RETRIES = 4
const {
  basicAutomation,
  newAutomation,
  automationTrigger,
  automationStep,
  collectAutomation,
  filterAutomation,
  updateRowAutomationWithFilters,
} = setup.structures

describe("/automations", () => {
  const config = new TestConfiguration()

  afterAll(() => {
    config.end()
  })

  beforeAll(async () => {
    await config.init()
  })

  beforeEach(() => {
    jest.clearAllMocks()
  })

  describe("get definitions", () => {
    it("returns a list of definitions for actions", async () => {
      const res = await config.api.automation.getActions()
      expect(Object.keys(res).length).not.toEqual(0)
    })

    it("returns a list of definitions for triggerInfo", async () => {
      const res = await config.api.automation.getTriggers()
      expect(Object.keys(res).length).not.toEqual(0)
    })

    it("returns all of the definitions in one", async () => {
      const { action, trigger } = await config.api.automation.getDefinitions()

      expect(Object.keys(action).length).toBeGreaterThanOrEqual(
        Object.keys(BUILTIN_ACTION_DEFINITIONS).length
      )
      expect(Object.keys(trigger).length).toEqual(
        Object.keys(TRIGGER_DEFINITIONS).length
      )
    })
  })

  describe("create", () => {
    it("creates an automation with no steps", async () => {
      const { message, automation } = await config.api.automation.post(
        newAutomation({ steps: [] })
      )

      expect(message).toEqual("Automation created successfully")
      expect(automation.name).toEqual("My Automation")
      expect(automation._id).not.toEqual(null)
      expect(events.automation.created).toHaveBeenCalledTimes(1)
      expect(events.automation.stepCreated).not.toHaveBeenCalled()
    })

    it("creates an automation with steps", async () => {
      jest.clearAllMocks()

      const { message, automation } = await config.api.automation.post(
        newAutomation({ steps: [automationStep(), automationStep()] })
      )

      expect(message).toEqual("Automation created successfully")
      expect(automation.name).toEqual("My Automation")
      expect(automation._id).not.toEqual(null)
      expect(events.automation.created).toHaveBeenCalledTimes(1)
      expect(events.automation.stepCreated).toHaveBeenCalledTimes(2)
    })

    it("Should ensure you can't have a branch as not a last step", async () => {
      const automation = createAutomationBuilder(config)
        .onAppAction()
        .branch({
          activeBranch: {
            steps: stepBuilder =>
              stepBuilder.serverLog({ text: "Active user" }),
            condition: {
              equal: { "trigger.fields.status": "active" },
            },
          },
        })
        .serverLog({ text: "Inactive user" })
        .build()

      await config.api.automation.post(automation, {
        status: 400,
        body: {
          message:
            "Invalid body - Branch steps are only allowed as the last step",
        },
      })
    })

    it("Should check validation on an automation that has a branch step with no children", async () => {
      const automation = createAutomationBuilder(config)
        .onAppAction()
        .branch({})
        .serverLog({ text: "Inactive user" })
        .build()

      await config.api.automation.post(automation, {
        status: 400,
        body: {
          message:
            'Invalid body - "definition.steps[0].inputs.branches" must contain at least 1 items',
        },
      })
    })

    it("Should check validation on a branch step with empty conditions", async () => {
      const automation = createAutomationBuilder(config)
        .onAppAction()
        .branch({
          activeBranch: {
            steps: stepBuilder =>
              stepBuilder.serverLog({ text: "Active user" }),
            condition: {},
          },
        })
        .build()

      await config.api.automation.post(automation, {
        status: 400,
        body: {
          message:
            'Invalid body - "definition.steps[0].inputs.branches[0].condition" must have at least 1 key',
        },
      })
    })

    it("Should check validation on an branch that has a condition that is not valid", async () => {
      const automation = createAutomationBuilder(config)
        .onAppAction()
        .branch({
          activeBranch: {
            steps: stepBuilder =>
              stepBuilder.serverLog({ text: "Active user" }),
            condition: {
              //@ts-ignore
              INCORRECT: { "trigger.fields.status": "active" },
            },
          },
        })
        .serverLog({ text: "Inactive user" })
        .build()

      await config.api.automation.post(automation, {
        status: 400,
        body: {
          message:
            'Invalid body - "definition.steps[0].inputs.branches[0].condition.INCORRECT" is not allowed',
        },
      })
    })

    it("should apply authorization to endpoint", async () => {
      const automation = newAutomation()
      await checkBuilderEndpoint({
        config,
        method: "POST",
        url: `/api/automations`,
        body: automation,
      })
    })
  })

  describe("find", () => {
    it("should be able to find the automation", async () => {
      const automation = await config.createAutomation()
      const { _id, _rev } = await config.api.automation.get(automation._id!)
      expect(_id).toEqual(automation._id)
      expect(_rev).toEqual(automation._rev)
    })
  })

  describe("run", () => {
    let oldConfig: SettingsConfig
    beforeAll(async () => {
      await context.doInTenant(config.getTenantId(), async () => {
        oldConfig = await configs.getSettingsConfigDoc()

        const settings: SettingsConfig = {
          _id: oldConfig._id,
          _rev: oldConfig._rev,
          type: ConfigType.SETTINGS,
          config: {
            platformUrl: "https://example.com",
            logoUrl: "https://example.com/logo.png",
            company: "Test Company",
          },
        }
        const saved = await configs.save(settings)
        oldConfig._rev = saved.rev
      })
    })

    afterAll(async () => {
      await context.doInTenant(config.getTenantId(), async () => {
        await configs.save(oldConfig)
      })
    })

    it("should be able to access platformUrl, logoUrl and company in the automation", async () => {
      const result = await createAutomationBuilder(config)
        .onAppAction()
        .serverLog({
          text: "{{ settings.url }}",
        })
        .serverLog({
          text: "{{ settings.logo }}",
        })
        .serverLog({
          text: "{{ settings.company }}",
        })
        .test({ fields: {} })

      expect(result.steps[0].outputs.message).toEndWith("https://example.com")
      expect(result.steps[1].outputs.message).toEndWith(
        "https://example.com/logo.png"
      )
      expect(result.steps[2].outputs.message).toEndWith("Test Company")
    })
  })

  describe("test", () => {
    it("tests the automation successfully", async () => {
      let table = await config.createTable()
      let automation = newAutomation()
      const triggerInputs = automation.definition.trigger
        .inputs as RowCreatedTriggerInputs
      triggerInputs.tableId = table._id!

      automation.definition.steps[0].inputs = {
        row: {
          name: "{{trigger.row.name}}",
          description: "{{trigger.row.description}}",
          tableId: table._id,
        },
      }
      automation.appId = config.getDevWorkspaceId()
      automation = await config.createAutomation(automation)
      await setup.delay(500)
      const res = await testAutomation(config, automation, {
        row: {
          name: "Test",
          description: "TEST",
        },
      })
      expect(events.automation.tested).toHaveBeenCalledTimes(1)
      // this looks a bit mad but we don't actually have a way to wait for a response from the automation to
      // know that it has finished all of its actions - this is currently the best way
      // also when this runs in CI it is very temper-mental so for now trying to make run stable by repeating until it works
      // TODO: update when workflow logs are a thing
      for (let tries = 0; tries < MAX_RETRIES; tries++) {
        expect(res.body).toBeDefined()
        await setup.delay(500)
        let elements = await getAllTableRows(config)
        // don't test it unless there are values to test
        if (elements.length >= 1) {
          expect(elements[0].name).toEqual("Test")
          expect(elements[0].description).toEqual("TEST")
          return
        }
      }
      throw "Failed to find the rows"
    })

    it("should be able to test a disabled automation", async () => {
      const { automation } = await config.api.automation.post(
        collectAutomation({ disabled: true })
      )

      await config.api.automation.test(automation._id!, {
        fields: {},
      })

      expect(sdk.automations.utils.checkForCollectStep(automation)).toEqual(
        true
      )
    })
  })

  describe("trigger", () => {
    it("does not trigger an automation when not synchronous and in dev", async () => {
      const { automation } = await config.api.automation.post(newAutomation())
      await config.api.automation.trigger(
        automation._id!,
        {
          fields: {},
          timeout: 1000,
        },
        {
          status: 400,
          body: {
            message: "Only apps in production support this endpoint",
          },
        }
      )
    })

    it("triggers a synchronous automation", async () => {
      mocks.licenses.useSyncAutomations()
      const { automation } =
        await config.api.automation.post(collectAutomation())
      await config.api.automation.trigger(
        automation._id!,
        {
          fields: {},
          timeout: 1000,
        },
        {
          status: 200,
          body: {
            success: true,
            value: [1, 2, 3],
          },
        }
      )
    })

    it("should throw an error when attempting to trigger a disabled automation", async () => {
      mocks.licenses.useSyncAutomations()
      const { automation } = await config.api.automation.post(
        collectAutomation({ disabled: true })
      )

      await config.api.automation.trigger(
        automation._id!,
        {
          fields: {},
          timeout: 1000,
        },
        {
          status: 400,
          body: {
            message: "Automation is disabled",
          },
        }
      )
    })

    it("triggers an asynchronous automation", async () => {
      const { automation } = await config.api.automation.post(newAutomation())
      await config.publish()

      await config.withProdApp(() =>
        config.api.automation.trigger(
          automation._id!,
          {
            fields: {},
            timeout: 1000,
          },
          {
            status: 200,
            body: {
              message: `Automation ${automation._id} has been triggered.`,
            },
          }
        )
      )
    })
  })

  describe("update", () => {
    it("updates a automations name", async () => {
      const { automation } = await config.api.automation.post(basicAutomation())
      automation.name = "Updated Name"
      jest.clearAllMocks()

      const { automation: updatedAutomation, message } =
        await config.api.automation.update(automation)

      expect(updatedAutomation._id).toEqual(automation._id)
      expect(updatedAutomation._rev).toBeDefined()
      expect(updatedAutomation._rev).not.toEqual(automation._rev)

      expect(updatedAutomation.name).toEqual("Updated Name")
      expect(message).toEqual(
        `Automation ${automation._id} updated successfully.`
      )

      expect(events.automation.created).not.toHaveBeenCalled()
      expect(events.automation.stepCreated).not.toHaveBeenCalled()
      expect(events.automation.stepDeleted).not.toHaveBeenCalled()
      expect(events.automation.triggerUpdated).not.toHaveBeenCalled()
    })

    it("updates a automations name using POST request", async () => {
      const { automation } = await config.api.automation.post(basicAutomation())
      automation.name = "Updated Name"
      jest.clearAllMocks()

      // the POST request will defer to the update when an id has been supplied.
      const { automation: updatedAutomation, message } =
        await config.api.automation.post(automation)

      expect(updatedAutomation._id).toEqual(automation._id)
      expect(updatedAutomation._rev).toBeDefined()
      expect(updatedAutomation._rev).not.toEqual(automation._rev)

      expect(updatedAutomation.name).toEqual("Updated Name")
      expect(message).toEqual(
        `Automation ${automation._id} updated successfully.`
      )

      expect(events.automation.created).not.toHaveBeenCalled()
      expect(events.automation.stepCreated).not.toHaveBeenCalled()
      expect(events.automation.stepDeleted).not.toHaveBeenCalled()
      expect(events.automation.triggerUpdated).not.toHaveBeenCalled()
    })

    it("updates an automation trigger", async () => {
      const { automation } = await config.api.automation.post(newAutomation())
      automation.definition.trigger = automationTrigger(
        TRIGGER_DEFINITIONS.WEBHOOK
      )
      jest.clearAllMocks()

      await config.api.automation.update(automation)

      expect(events.automation.created).not.toHaveBeenCalled()
      expect(events.automation.stepCreated).not.toHaveBeenCalled()
      expect(events.automation.stepDeleted).not.toHaveBeenCalled()
      expect(events.automation.triggerUpdated).toHaveBeenCalledTimes(1)
    })

    it("adds automation steps", async () => {
      const { automation } = await config.api.automation.post(newAutomation())
      automation.definition.steps.push(automationStep())
      automation.definition.steps.push(automationStep())
      jest.clearAllMocks()

      await config.api.automation.update(automation)

      expect(events.automation.stepCreated).toHaveBeenCalledTimes(2)
      expect(events.automation.created).not.toHaveBeenCalled()
      expect(events.automation.stepDeleted).not.toHaveBeenCalled()
      expect(events.automation.triggerUpdated).not.toHaveBeenCalled()
    })

    it("removes automation steps", async () => {
      const { automation } = await config.api.automation.post(newAutomation())
      automation.definition.steps = []
      jest.clearAllMocks()

      await config.api.automation.update(automation)

      expect(events.automation.stepDeleted).toHaveBeenCalledTimes(1)
      expect(events.automation.stepCreated).not.toHaveBeenCalled()
      expect(events.automation.created).not.toHaveBeenCalled()
      expect(events.automation.triggerUpdated).not.toHaveBeenCalled()
    })

    it("adds and removes automation steps", async () => {
      const { automation } = await config.api.automation.post(newAutomation())
      automation.definition.steps = [automationStep(), automationStep()]
      jest.clearAllMocks()

      await config.api.automation.update(automation)

      expect(events.automation.stepCreated).toHaveBeenCalledTimes(2)
      expect(events.automation.stepDeleted).toHaveBeenCalledTimes(1)
      expect(events.automation.created).not.toHaveBeenCalled()
      expect(events.automation.triggerUpdated).not.toHaveBeenCalled()
    })

    it("can update an input field", async () => {
      const { automation } = await createAutomationBuilder(config)
        .onRowDeleted({ tableId: "tableId" })
        .serverLog({ text: "test" })
        .save()

      const triggerInputs = automation.definition.trigger
        .inputs as RowDeletedTriggerInputs

      triggerInputs.tableId = "newTableId"
      const { automation: updatedAutomation } =
        await config.api.automation.update(automation)

      const updatedTriggerInputs = updatedAutomation.definition.trigger
        .inputs as RowDeletedTriggerInputs

      expect(updatedTriggerInputs.tableId).toEqual("newTableId")
    })

    it("cannot update a readonly field", async () => {
      const { automation } = await createAutomationBuilder(config)
        .onRowAction({ tableId: "tableId", rowActionId: "someId" })
        .serverLog({ text: "test" })
        .save()

      const triggerInputs = automation.definition.trigger
        .inputs as RowActionTriggerInputs
      triggerInputs.tableId = "newTableId"
      await config.api.automation.update(automation, {
        status: 400,
        body: {
          message: "Field tableId is readonly and it cannot be modified",
        },
      })
    })
  })

  describe("fetch", () => {
    it("return all the automations for an instance", async () => {
      const { automation: automation1 } =
        await config.api.automation.post(newAutomation())
      const { automation: automation2 } =
        await config.api.automation.post(newAutomation())
      const { automation: automation3 } =
        await config.api.automation.post(newAutomation())

      const { automations } = await config.api.automation.fetch()
      expect(automations).toEqual(
        expect.arrayContaining([automation1, automation2, automation3])
      )
    })

    it("should apply authorization to endpoint", async () => {
      await checkBuilderEndpoint({
        config,
        method: "GET",
        url: `/api/automations`,
      })
    })
  })

  describe("destroy", () => {
    it("deletes a automation by its ID", async () => {
      const { automation } = await config.api.automation.post(newAutomation())
      const { id } = await config.api.automation.delete(automation)

      expect(id).toEqual(automation._id)
      expect(events.automation.deleted).toHaveBeenCalledTimes(1)
    })

    it("cannot delete a row action automation", async () => {
      const { automation } = await config.api.automation.post(
        setup.structures.rowActionAutomation()
      )

      await config.api.automation.delete(automation, {
        status: 422,
        body: {
          message: "Row actions automations cannot be deleted",
          status: 422,
        },
      })

      expect(events.automation.deleted).not.toHaveBeenCalled()
    })

    it("should apply authorization to endpoint", async () => {
      const automation = await config.createAutomation()
      await checkBuilderEndpoint({
        config,
        method: "DELETE",
        url: `/api/automations/${automation._id}/${automation._rev}`,
      })
    })
  })

  describe("checkForCollectStep", () => {
    it("should return true if a collect step exists in an automation", async () => {
      const { automation } =
        await config.api.automation.post(collectAutomation())
      expect(sdk.automations.utils.checkForCollectStep(automation)).toEqual(
        true
      )
    })

    it("should return false if a collect step does not exist in an automation", async () => {
      const { automation } = await config.api.automation.post(newAutomation())
      expect(sdk.automations.utils.checkForCollectStep(automation)).toEqual(
        false
      )
    })
  })

  describe("Update Row Old / New Row comparison", () => {
    it.each([
      { oldCity: "asdsadsadsad", newCity: "new" },
      { oldCity: "Belfast", newCity: "Belfast" },
    ])(
      "triggers an update row automation and compares new to old rows with old city '%s' and new city '%s'",
      async ({ oldCity, newCity }) => {
        let table = await config.api.table.save(basicTable())

        const { automation } = await config.api.automation.post(
          filterAutomation({
            definition: {
              trigger: {
                inputs: {
                  tableId: table._id,
                },
              },
              steps: [
                {
                  inputs: {
                    condition: FilterCondition.EQUAL,
                    field: "{{ trigger.row.City }}",
                    value: "{{ trigger.oldRow.City }}",
                  },
                },
              ],
            },
          })
        )

        const response = await config.api.automation.test(automation._id!, {
          fields: {},
          oldRow: {
            City: oldCity,
          },
          row: {
            City: newCity,
          },
        })

        if (isDidNotTriggerResponse(response)) {
          throw new Error("Automation did not trigger")
        }

        const results: AutomationResults = response as AutomationResults

        const expectedResult = oldCity === newCity
        expect(results.steps[1].outputs.result).toEqual(expectedResult)
      }
    )
  })
  describe("Automation Update / Creator row trigger filtering", () => {
    let table: Table

    beforeAll(async () => {
      table = await config.api.table.save(
        basicTable(undefined, {
          name: "table",
          type: "table",
          schema: {
            Approved: {
              name: "Approved",
              type: FieldType.BOOLEAN,
            },
          },
        })
      )
    })

    const testCases = [
      {
        description: "should run when Approved changes from false to true",
        filters: {
          equal: { "1:Approved": true },
        },
        row: { Approved: "true" },
        oldRow: { Approved: "false" },
        expectToRun: true,
      },
      {
        description: "should run when Approved is true in both old and new row",
        filters: { equal: { "1:Approved": true } },
        row: { Approved: "true" },
        oldRow: { Approved: "true" },
        expectToRun: true,
      },

      {
        description:
          "should run when a contains filter matches the correct options",
        filters: {
          contains: { "1:opts": ["Option 1", "Option 3"] },
        },
        row: { opts: ["Option 1", "Option 3"] },
        oldRow: { opts: ["Option 3"] },
        expectToRun: true,
      },
      {
        description:
          "should not run when opts doesn't contain any specified option",
        filters: {
          contains: { "1:opts": ["Option 1", "Option 2"] },
        },
        row: { opts: ["Option 3", "Option 4"] },
        oldRow: { opts: ["Option 3", "Option 4"] },
        expectToRun: false,
      },
    ]

    it.each(testCases)(
      "$description",
      async ({ filters, row, oldRow, expectToRun }) => {
        let req = updateRowAutomationWithFilters(
          config.getDevWorkspaceId(),
          table._id!
        )
        req.definition.trigger.inputs = {
          tableId: table._id,
          filters,
        }

        const { automation } = await config.api.automation.post(req)
        const res = await config.api.automation.test(automation._id!, {
          fields: {},
          oldRow: {
            tableId: table._id,
            ...oldRow,
          },
          row: {
            tableId: table._id,
            ...row,
          },
        })

        if (isDidNotTriggerResponse(res)) {
          expect(expectToRun).toEqual(false)
        } else {
          const results: AutomationResults = res as AutomationResults
          expect(results.steps[1].outputs.success).toEqual(expectToRun)
        }
      }
    )
  })

  describe("email trigger secrets", () => {
    const ensureEmailTrigger = (
      trigger?: Automation["definition"]["trigger"]
    ): EmailTrigger => {
      if (!trigger || !isEmailTrigger(trigger)) {
        throw new Error("Expected email trigger")
      }
      return trigger
    }

    const buildEmailAutomation = (password = "imap-secret") => {
      const trigger: EmailTrigger = {
        ...automationTrigger(TRIGGER_DEFINITIONS.EMAIL),
        stepId: AutomationTriggerStepId.EMAIL,
        inputs: {
          host: "imap.gmail.com",
          port: 993,
          secure: true,
          username: "dom",
          password,
          mailbox: "dom",
        } satisfies EmailTriggerInputs,
      }
      return newAutomation({
        trigger,
        steps: [],
      })
    }

    const fetchStoredAutomation = async (automationId: string) => {
      return context.doInWorkspaceContext(
        config.getDevWorkspaceId(),
        async () => {
          const db = context.getWorkspaceDB()
          return db.get<Automation>(automationId)
        }
      )
    }

    it("masks the trigger password in API responses", async () => {
      const payload = buildEmailAutomation()
      const { automation: createdAutomation, message } =
        await config.api.automation.post(payload)

      expect(message).toEqual("Automation created successfully")
      const createdTrigger = ensureEmailTrigger(
        createdAutomation.definition.trigger
      )
      expect(createdTrigger.inputs.password).toMatch(/^\*+$/)

      const stored = await fetchStoredAutomation(createdAutomation._id!)
      const storedTrigger = ensureEmailTrigger(stored.definition.trigger)
      expect(storedTrigger.inputs.password).toEqual("imap-secret")

      const { automations } = await config.api.automation.fetch()
      const fetched = automations.find(
        automation => automation._id === createdAutomation._id
      )
      if (!fetched) {
        throw new Error("Expected automation to be returned from fetch()")
      }
      const fetchedTrigger = ensureEmailTrigger(fetched.definition.trigger)
      expect(fetchedTrigger.inputs.password).toMatch(/^\*+$/)
    })

    it("reuses the stored password when the placeholder is submitted", async () => {
      const payload = buildEmailAutomation("mail-secret")
      const { automation: createdAutomation } =
        await config.api.automation.post(payload)

      createdAutomation.name = "Updated Email Monitor"
      const createdTrigger = ensureEmailTrigger(
        createdAutomation.definition.trigger
      )
      createdTrigger.inputs.mailbox = "alerts"

      const { automation: updatedAutomation } =
        await config.api.automation.update(createdAutomation)

      const updatedTrigger = ensureEmailTrigger(
        updatedAutomation.definition.trigger
      )
      expect(updatedTrigger.inputs.password).toMatch(/^\*+$/)

      const stored = await fetchStoredAutomation(updatedAutomation._id!)
      const storedTrigger = ensureEmailTrigger(stored.definition.trigger)
      expect(storedTrigger.inputs.password).toEqual("mail-secret")
      expect(storedTrigger.inputs.mailbox).toEqual("alerts")
    })
  })
})
